Skip to content

fix(mocks): implement indexer in generated mock (#5676)#5683

Merged
thomhurst merged 3 commits intomainfrom
fix/mocks-indexer-5676
Apr 23, 2026
Merged

fix(mocks): implement indexer in generated mock (#5676)#5683
thomhurst merged 3 commits intomainfrom
fix/mocks-indexer-5676

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • Implements interface/partial/wrap indexer emission in MockImplBuilder, replacing the three if (prop.IsIndexer) continue; skip sites that were leaving IFoo.this[int] unimplemented and producing CS0535.
  • Adds Item(args) and SetItem(args, value) extension methods in MockMembersBuilder so per-index setups (Returns) and verifications (WasCalled) work via the existing MockMethodCall<T> / VoidMockMethodCall plumbing.
  • Restores the SKIPPED T14 kitchen-sink tests covering contract satisfaction, the regular-property co-resident path, per-index Returns/WasCalled, and setter verification with mixed Any<T>() / literal matchers.

Why

Any interface with an indexer was previously unmockable — even if the consumer only needed the other members. The full-support route was small (the indexer model was already discovered) and avoids shipping a stub that would have made IList-style mocks throw at runtime.

Known limitations

  • MockWrapperTypeBuilder.CanGenerateWrapper still returns false when an indexer is present, so the typed IFoo_Mock wrapper is not emitted for indexer-bearing interfaces. The base Mock<IFoo> API works fully; only the typed-wrapper sugar is unavailable. Out of scope for this PR.
  • Static-abstract indexers in MockBridgeBuilder are still skipped (separate code path, no interface contract failure).

Test plan

  • T14 (2 tests in TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs) pass
  • TUnit.Mocks.Tests full suite (net10.0): 954/954
  • TUnit.Mocks.SourceGenerator.Tests (net10.0): 45/45
  • TUnit.Mocks.Analyzers.Tests (net10.0): 30/30

Closes #5676

Mocking an interface that declares an indexer produced CS0535 because
the generated MockImpl class declared the interface in its base list but
MockImplBuilder skipped indexer emission entirely. The contract was
therefore unimplemented at compile time.

Generate the indexer in the impl (interface, partial, and wrap variants)
and route both get and set through the engine using the index parameters
(plus value, for setters) as the matchable arguments. Also surface
configuration and verification via Item(args)/SetItem(args, value)
extensions on Mock<T>, returning the existing MockMethodCall<T> /
VoidMockMethodCall types so distinct index values produce independent
setups and counts.

Restores the T14 KitchenSinkEdgeCases case that was previously skipped.
GeneratePartialIndexer and GenerateWrapIndexer were 95% identical -- only
the fallback target differed (`base[...]` vs `_wrappedInstance[...]`). Both
now delegate to a shared GenerateOverrideIndexer helper parameterised by
that target, and the manual `AppendLine("{") + IncreaseIndent` brace dance
inside the engine-miss fallback blocks is replaced with the standard
OpenBrace/CloseBrace pair.

No change to generated output; T14 + full Mocks/SourceGenerator/Analyzers
test suites still green.
@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Apr 23, 2026

Not up to standards ⛔

🔴 Issues 3 minor

Alerts:
⚠ 3 issues (≤ 0 issues of at least minor severity)

Results:
3 new issues

Category Results
CodeStyle 3 minor

View in Codacy

🟢 Metrics 24 complexity

Metric Results
Complexity 24

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: fix(mocks): implement indexer in generated mock (#5676)

Overall, this is a well-structured and complete fix. The approach of dispatching to dedicated GenerateInterfaceIndexer / GenerateOverrideIndexer methods — rather than simply un-skipping the existing property path — is the right call; indexers have different codegen shape (no named member, this[…] syntax, implicit value in setters) and would have needed if-guards throughout the property generator. The fallback semantics in GenerateOverrideIndexer cleanly mirror the existing override-property pattern.


Things done well

  • Fallback correctness. The try-then-fallback pattern in GenerateOverrideIndexer for non-abstract members (TryHandleCallWithReturn / TryHandleCall, then delegate to base[…] or _wrappedInstance[…]) correctly mirrors the same pattern used in the existing override property helpers.

  • Extension method naming. Choosing Item(…) / SetItem(…, value) maps directly to the get_Item / set_Item IL names used in the engine call strings. Consistent and unsurprising.

  • Edge-case allocation. Using Array.Empty<object?>() in the zero-parameter getter path (and for the matcher list) avoids a pointless allocation. The setter path always allocates (because value must be in there), which is correct.

  • Test coverage. The two T14 tests cover: compile with a co-resident regular property, distinct-index independence, exact call-count verification, mixed literal / wildcard matchers, and setter verification. That's a solid regression baseline.


Issues and suggestions

1. XML doc comments on private methods — against project coding standards

GenerateInterfaceIndexer, GeneratePartialIndexer, GenerateWrapIndexer, and GenerateOverrideIndexer all carry multi-line <summary> blocks. CLAUDE.md is explicit: "Default to writing no comments. Only add one when the WHY is non-obvious." Private generator helpers with self-documenting names don't meet that bar; the /// <summary> blocks describe what the code does (already clear from the name) rather than why a non-obvious constraint exists. Removing them keeps the file consistent with the rest of the builder.

2. FormatIndexerParameterList silently drops parameter modifiers

private static string FormatIndexerParameterList(MockMemberModel indexer)
{
    return string.Join(", ", indexer.Parameters.Select(p => $"{p.FullyQualifiedType} {p.Name}"));
}

This only emits type name; it doesn't emit in, ref, or out modifiers. Indexers with in T key parameters (a valid C# pattern) would generate code that doesn't compile. Consider checking whether MockMemberModel.ParameterModel carries modifier information and forwarding it here, similarly to how the existing method-parameter formatting handles it. If parameter-with-modifiers indexers are genuinely impossible in the current model, a guard or comment is worthwhile so future model changes don't silently break this.

3. argsArray is computed unconditionally in two places

In GenerateInterfaceIndexer:

var argsArray = GetIndexerGetterArgsArray(prop);   // always runs
// ...
if (prop.HasGetter)
{
    writer.AppendLine($"get => ... {argsArray} ...");
}

And identically in GenerateOverrideIndexer. If the indexer is setter-only, argsArray is built but never used. This is a very minor point (it's pure string work), but it's also trivially fixed by moving the variable inside the if (prop.HasGetter) block, which improves reading clarity (the reader sees the value and its use together).

4. Known limitation — typed wrapper not emitted for indexer interfaces

The PR description acknowledges MockWrapperTypeBuilder.CanGenerateWrapper still returns false for indexer-bearing interfaces. Worth tracking as a follow-up issue if it isn't already filed, because the IFoo_Mock typed wrapper is the primary ergonomic surface for most consumers; landing without it means indexer-bearing interface users get Mock<IFoo> which is functional but less convenient.


Snapshot tests

CLAUDE.md requires that changes to source generator output have their .verified.txt snapshots committed. The diff doesn't include snapshot files — assuming they were reviewed and are committed, this is fine. If they haven't been updated, running the snapshot-acceptance loop described in CLAUDE.md is needed before merge.


Verdict: The core implementation is correct and well-designed. The two issues worth addressing before merge are the XML doc comments (minor, style) and the missing parameter-modifier forwarding in FormatIndexerParameterList (correctness gap for a currently-rare but valid input). The unconditional argsArray is cosmetic only.

- Drop XML doc blocks from private indexer-emission helpers; the
  member names are self-describing per project standards.
- Forward parameter modifiers in indexer signatures: discovery now
  uses GetParameterDirection() instead of hardcoding In, and
  FormatIndexerParameterList delegates to FormatParameterList so
  `in T key` indexers compile.
- Move argsArray inside `if (HasGetter)` in interface and override
  indexer emitters; setters compute their own args.
- Add T14b test exercising an `in` parameter on an indexer.
@thomhurst
Copy link
Copy Markdown
Owner Author

Applied review feedback in 88f7776.

Applied

  1. Removed <summary> doc blocks from GenerateInterfaceIndexer, GeneratePartialIndexer, GenerateWrapIndexer, and GenerateOverrideIndexer. Names are self-describing per project standards.
  2. Fixed the modifier-drop bug. Two changes were needed:
    • MemberDiscovery.CreateIndexerModel was hardcoding Direction = ParameterDirection.In for indexer parameters; now uses p.GetParameterDirection() (the same helper method/event discovery uses).
    • FormatIndexerParameterList now delegates to the existing FormatParameterList, so the modifier prefix (in , etc.) is forwarded.
    • Added T14b: IHasInIndexer with string this[in int key] exercising the path. Generated impl now correctly emits public string this[in int key].
  3. Moved argsArray inside if (HasGetter) in GenerateInterfaceIndexer and GenerateOverrideIndexer; setters compute their own args separately.
  4. dotnet format --verify-no-changes reports several pre-existing whitespace/import issues in unrelated files (MemberDiscovery.cs line 78, MockTypeDiscovery.cs, EquatableArray.cs, MockParameterModel.cs, plus older lines of KitchenSinkEdgeCasesTests.cs). None are in the lines this PR touches, so left alone to keep the diff scoped. Codacy's specific findings still need to be viewed on their dashboard.

Test status (net10.0)

  • TUnit.Mocks.Tests: 955/955 passing
  • TUnit.Mocks.SourceGenerator.Tests: 45/45 passing
  • TUnit.Mocks.Analyzers.Tests: 30/30 passing

Deferred / out-of-scope

  • MockWrapperTypeBuilder.CanGenerateWrapper returning false for indexer interfaces remains a known limitation per the original PR body. Happy to file a follow-up issue if you'd like — let me know.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Implement Indexer in Generated Mock

Summary

This PR addresses #5676 by implementing full indexer support in the TUnit mocks code generator. Previously, interfaces with indexers were unmockable — the generator skipped them with if (prop.IsIndexer) continue;, producing CS0535 compilation errors. This PR:

  1. Implements indexer emission across three code paths (interface, partial, wrapper mocks)
  2. Adds Item(...)/SetItem(..., value) extension methods in MockMembersBuilder for fluent setup/verification
  3. Restores previously-skipped tests (T14, T14b) in KitchenSinkEdgeCasesTests.cs
  4. Fixes parameter direction handling for indexer parameters (in modifiers)

Architectural Assessment: Well-Designed

Three-path dispatch strategy is correct. The PR correctly separates:

  • Interface mocks — direct engine routing (no fallback needed since all members are abstract)
  • Partial mocks — try-intercept with base fallback
  • Wrapper mocks — try-intercept with _wrappedInstance fallback

This mirrors exactly how methods and properties are handled, maintaining consistency. The try-intercept pattern allows non-mocked indices to fall through to real implementations, which is the correct semantic.

Extension method API is idiomatic. Exposing mock.Item(5).Returns("five") and mock.SetItem(5, "five").WasCalled() reuses MockMethodCall<T> and VoidMockMethodCall without modification — a strong sign the abstraction is being applied correctly rather than bent.

MemberDiscovery.cs fix is important. Replacing the hardcoded Direction = ParameterDirection.In with p.GetParameterDirection() was a correctness bug, not just a style issue. Without this, in-parameter indexers would be silently mishandled in generated code.


Issues Found

1. Multi-parameter indexer coverage gap (LOW risk, but worth noting)

There are no tests for indexers with multiple parameters (e.g., this[int x, int y]). The logic in GetIndexerGetterArgsArray() and GetIndexerSetterArgsArray() appears correct for this case — it iterates all parameters — but a test would confirm the generated Item(x, y) extension method compiles and dispatches correctly. Worth a follow-up issue if not in scope now.

2. Wrapper type generation remains disabled (acknowledged limitation)

MockWrapperTypeBuilder.CanGenerateWrapper still returns false for indexer-bearing interfaces. This is correctly acknowledged as out of scope. A follow-up issue tracking this would prevent it from being forgotten.

3. Static-abstract indexers remain unhandled (acknowledged limitation)

MockBridgeBuilder still skips static-abstract indexers. Also correctly scoped out.


What's Done Well

  • The parameter direction fix in MemberDiscovery.cs is a genuine correctness improvement beyond just the feature work
  • Tests T14a/T14b/T14c cover the golden path, distinct-index isolation, wildcard matchers, and in parameters
  • Generated code follows the same HandleCallWithReturn/HandleCall dispatch pattern as existing methods — no new engine surface area needed
  • All 954+ existing tests continue to pass

Verdict

Approve. The implementation is sound, consistent with existing patterns, and well-tested for the primary use cases. The known limitations are properly scoped and acknowledged. The parameter direction fix is a bonus correctness improvement.

The only actionable suggestion is to consider opening follow-up issues for: (1) multi-parameter indexer tests, (2) wrapper type generation for indexer interfaces, so these don't get lost.

This was referenced Apr 24, 2026
intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 25, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.37.10 to
1.39.0.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.39.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.39.0 -->

## What's Changed
### Other Changes
* perf(mocks): shrink MethodSetup + cache stateless matchers by
@​thomhurst in thomhurst/TUnit#5669
* fix(mocks): handle base classes with explicit interface impls (#​5673)
by @​thomhurst in thomhurst/TUnit#5674
* fix(mocks): implement indexer in generated mock (#​5676) by
@​thomhurst in thomhurst/TUnit#5683
* fix(mocks): disambiguate IEquatable<T>.Equals from object.Equals
(#​5675) by @​thomhurst in thomhurst/TUnit#5680
* fix(mocks): escape C# keyword identifiers at all emit sites (#​5679)
by @​thomhurst in thomhurst/TUnit#5684
* fix(mocks): emit [SetsRequiredMembers] on generated mock ctor (#​5678)
by @​thomhurst in thomhurst/TUnit#5682
* fix(mocks): skip MockBridge for class targets with static-abstract
interfaces (#​5677) by @​thomhurst in
thomhurst/TUnit#5681
* chore(mocks): regenerate source generator snapshots by @​thomhurst in
thomhurst/TUnit#5691
* perf(engine): collapse async state-machine layers on hot test path
(#​5687) by @​thomhurst in thomhurst/TUnit#5690
* perf(engine): reduce lock contention in scheduling and hook caches
(#​5686) by @​thomhurst in thomhurst/TUnit#5693
* fix(assertions): prevent implicit-to-string op from NREing on null
(#​5692) by @​thomhurst in thomhurst/TUnit#5696
* perf(engine/core): reduce per-test allocations (#​5688) by @​thomhurst
in thomhurst/TUnit#5694
* perf(engine): reduce message-bus contention on test start (#​5685) by
@​thomhurst in thomhurst/TUnit#5695
### Dependencies
* chore(deps): update tunit to 1.37.36 by @​thomhurst in
thomhurst/TUnit#5667
* chore(deps): update verify to 31.16.2 by @​thomhurst in
thomhurst/TUnit#5699


**Full Changelog**:
thomhurst/TUnit@v1.37.36...v1.39.0

## 1.37.36

<!-- Release notes generated using configuration in .github/release.yml
at v1.37.36 -->

## What's Changed
### Other Changes
* fix(telemetry): remove duplicate HTTP client spans by @​thomhurst in
thomhurst/TUnit#5668


**Full Changelog**:
thomhurst/TUnit@v1.37.35...v1.37.36

## 1.37.35

<!-- Release notes generated using configuration in .github/release.yml
at v1.37.35 -->

## What's Changed
### Other Changes
* Add TUnit.TestProject.Library to the TUnit.Dev.slnx solution file by
@​Zodt in thomhurst/TUnit#5655
* fix(aspire): preserve user-supplied OTLP endpoint (#​4818) by
@​thomhurst in thomhurst/TUnit#5665
* feat(aspire): emit client spans for HTTP by @​thomhurst in
thomhurst/TUnit#5666
### Dependencies
* chore(deps): update dependency dotnet-sdk to v10.0.203 by @​thomhurst
in thomhurst/TUnit#5656
* chore(deps): update microsoft.aspnetcore to 10.0.7 by @​thomhurst in
thomhurst/TUnit#5657
* chore(deps): update tunit to 1.37.24 by @​thomhurst in
thomhurst/TUnit#5659
* chore(deps): update microsoft.extensions to 10.0.7 by @​thomhurst in
thomhurst/TUnit#5658
* chore(deps): update aspire to 13.2.3 by @​thomhurst in
thomhurst/TUnit#5661
* chore(deps): update dependency microsoft.net.test.sdk to 18.5.0 by
@​thomhurst in thomhurst/TUnit#5664

## New Contributors
* @​Zodt made their first contribution in
thomhurst/TUnit#5655

**Full Changelog**:
thomhurst/TUnit@v1.37.24...v1.37.35

## 1.37.24

<!-- Release notes generated using configuration in .github/release.yml
at v1.37.24 -->

## What's Changed
### Other Changes
* docs: add Tluma Ask AI widget to Docusaurus site by @​thomhurst in
thomhurst/TUnit#5638
* Revert "chore(deps): update dependency docusaurus-plugin-llms to
^0.4.0 (#​5637)" by @​thomhurst in
thomhurst/TUnit#5640
* fix(asp-net): forward disposal in FlowSuppressingHostedService
(#​5651) by @​JohnVerheij in
thomhurst/TUnit#5652
### Dependencies
* chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 by
@​thomhurst in thomhurst/TUnit#5637
* chore(deps): update tunit to 1.37.10 by @​thomhurst in
thomhurst/TUnit#5639
* chore(deps): update opentelemetry to 1.15.3 by @​thomhurst in
thomhurst/TUnit#5645
* chore(deps): update opentelemetry by @​thomhurst in
thomhurst/TUnit#5647
* chore(deps): update dependency dompurify to v3.4.1 by @​thomhurst in
thomhurst/TUnit#5648
* chore(deps): update dependency system.commandline to 2.0.7 by
@​thomhurst in thomhurst/TUnit#5650
* chore(deps): update dependency microsoft.entityframeworkcore to 10.0.7
by @​thomhurst in thomhurst/TUnit#5649
* chore(deps): update dependency microsoft.templateengine.authoring.cli
to v10.0.203 by @​thomhurst in
thomhurst/TUnit#5653
* chore(deps): update dependency
microsoft.templateengine.authoring.templateverifier to 10.0.203 by
@​thomhurst in thomhurst/TUnit#5654


**Full Changelog**:
thomhurst/TUnit@v1.37.10...v1.37.24

Commits viewable in [compare
view](thomhurst/TUnit@v1.37.10...v1.39.0).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.37.10&new-version=1.39.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] TUnit.Mocks: interface with indexer fails to compile — CS0535 on generated mock impl

1 participant